package org.netwia.networks;

import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.netwia.utils.DomainUtils;
import org.netwia.utils.ValidationUtils;

/**
 * Defines the network. Network connects together nodes, inputs and outputs.
 * Every node has a unique name within the network.
 * 
 * @author radek.hecl
 *
 * @param <T> network parameter type
 */
public class Network<T> {

    /**
     * Builder object.
     *
     * @param <T> network parameter type
     */
    public static class Builder<T> {

        /**
         * Nodes.
         */
        private Map<String, Node<T>> nodes = new HashMap<String, Node<T>>();

        /**
         * Inputs for all nodes.
         */
        private Map<String, Map<String, InputProvider<T>>> inputs = new HashMap<String, Map<String, InputProvider<T>>>();

        /**
         * Outputs for all nodes.
         */
        private Map<String, Set<OutputListener<T>>> outputs = new HashMap<String, Set<OutputListener<T>>>();

        /**
         * Adds the specified node.
         * 
         * @param name name of the node
         * @param node node to be add
         * @return this instance
         */
        public Builder<T> addNode(String name, Node<T> node) {
            nodes.put(name, node);
            return this;
        }

        /**
         * Adds input to the specified node.
         * 
         * @param nodeName name of the node
         * @param inputName name of the input
         * @param input input
         * @return this instance
         */
        public Builder<T> addInput(String nodeName, String inputName, InputProvider<T> input) {
            if (!inputs.containsKey(nodeName)) {
                inputs.put(nodeName, new HashMap<String, InputProvider<T>>());
            }
            inputs.get(nodeName).put(inputName, input);
            return this;
        }

        /**
         * Adds output to the specified node.
         * 
         * @param nodeName name of the node
         * @param output output
         * @return this instance
         */
        public Builder<T> addOutput(String nodeName, OutputListener<T> output) {
            if (!outputs.containsKey(nodeName)) {
                outputs.put(nodeName, new HashSet<OutputListener<T>>());
            }
            outputs.get(nodeName).add(output);
            return this;
        }

        /**
         * Builds the result object.
         * 
         * @return created object
         */
        public Network<T> build() {
            return new Network<T>(this);
        }
    }

    /**
     * Nodes.
     */
    private Map<String, Node<T>> nodes;

    /**
     * Inputs for all nodes.
     */
    private Map<String, Map<String, InputProvider<T>>> inputs;

    /**
     * Outputs for all nodes.
     */
    private Map<String, Set<OutputListener<T>>> outputs;

    /**
     * Creates new instance.
     * 
     * @param builder builder object
     */
    public Network(Builder<T> builder) {
        nodes = Collections.unmodifiableMap(DomainUtils.softCopyMap(builder.nodes));
        inputs = new HashMap<String, Map<String, InputProvider<T>>>();
        for (String key : builder.inputs.keySet()) {
            inputs.put(key, Collections.unmodifiableMap(builder.inputs.get(key)));
        }
        inputs = Collections.unmodifiableMap(inputs);
        outputs = new HashMap<String, Set<OutputListener<T>>>();
        for (String key : builder.outputs.keySet()) {
            outputs.put(key, Collections.unmodifiableSet(builder.outputs.get(key)));
        }
        outputs = Collections.unmodifiableMap(outputs);
        guardInvariants();
    }

    /**
     * Guards this object to be consistent. Throws exception if this is not the case.
     */
    private void guardInvariants() {
        ValidationUtils.guardNotNullMap(nodes, "nodes cannot have null element");
        ValidationUtils.guardNotEmptyStringInCollection(nodes.keySet(), "nodes must have all names not empty");
        if (!nodes.keySet().containsAll(inputs.keySet())) {
            throw new IllegalArgumentException("inputs are allowed only for the registered nodes: nodes = " + nodes.keySet() + "; " +
                    "inputs = " + inputs.keySet());
        }
        for (String key : inputs.keySet()) {
            ValidationUtils.guardNotNullMap(inputs.get(key), "input cannot be null");
            ValidationUtils.guardNotEmptyStringInCollection(inputs.get(key).keySet(), "input cannot have empty name");
        }
        if (!nodes.keySet().containsAll(outputs.keySet())) {
            throw new IllegalArgumentException("outputs are allowed only for the registered nodes: nodes = " + nodes.keySet() + "; " +
                    "outputs = " + outputs.keySet());
        }
        for (String key : outputs.keySet()) {
            ValidationUtils.guardNotNullCollection(outputs.get(key), "output cannot be null");
        }
        // check no input is used twice
        Set<InputProvider<T>> allInputs = new HashSet<InputProvider<T>>();
        for (String key : inputs.keySet()) {
            for (InputProvider<T> input : inputs.get(key).values()) {
                if (allInputs.contains(input)) {
                    throw new IllegalArgumentException("one input cannot be used twice: inputProvider = " + input);
                }
                allInputs.add(input);
            }
        }
        // check no output is used twice
        Set<OutputListener<T>> allOutputs = new HashSet<OutputListener<T>>();
        for (String key : outputs.keySet()) {
            for (OutputListener<T> output : outputs.get(key)) {
                if (allOutputs.contains(output)) {
                    throw new IllegalArgumentException("one output cannot be used twice: outputListener = " + output);
                }
                allOutputs.add(output);
            }
        }

    }

    /**
     * Returns all node names within this network.
     * 
     * @return node names
     */
    public Set<String> getNodeNames() {
        return nodes.keySet();
    }

    /**
     * Returns the specified node.
     * 
     * @param node name of the node
     * @return node with the specified name
     */
    public Node<T> getNode(String node) {
        return nodes.get(node);
    }

    /**
     * Returns inputs for the specified node.
     * 
     * @param node name of the node
     * @return inputs for the specified node, key is name of the input as it goes to the node
     */
    public Map<String, InputProvider<T>> getInputs(String node) {
        Map<String, InputProvider<T>> res = inputs.get(node);
        if (res == null) {
            res = Collections.emptyMap();
        }
        return res;
    }

    /**
     * Returns outputs for the specified node.
     * 
     * @param node name of the node
     * @return outputs for the specified node
     */
    public Set<OutputListener<T>> getOutputs(String node) {
        Set<OutputListener<T>> res = outputs.get(node);
        if (res == null) {
            res = Collections.emptySet();
        }
        return res;
    }

    @Override
    public int hashCode() {
        return HashCodeBuilder.reflectionHashCode(this);
    }

    @Override
    public boolean equals(Object obj) {
        return EqualsBuilder.reflectionEquals(this, obj);
    }

    @Override
    public String toString() {
        return ToStringBuilder.reflectionToString(this);
    }
    
}
